feat(panel): timed global mute + CLI-only Claude usage probe#130
Conversation
Add a "mute everything for X minutes" control that suppresses every interrupting output — banner, sound, voice, and the activate-immediately focus jump — for all events including permission prompts, then auto-lifts when the timer expires. Events still flow into the panel while muted. The mute is transient (in-memory only, resets on relaunch); a single gate at the top of postBannerIfNeeded does the suppression. The default one-click duration is a config-backed preference (STACKNUDGE_MUTE_DURATION_MIN, 15/30/60/120, default 30). Surfaces: - Panel header bell button with live countdown - Menu-bar submenu (For 15m/30m/1h/2h) + bell.slash icon while muted - Settings: Mute/Resume action row (bell glyph, not a nav chevron), a muted-only top banner with one-click Resume, and the duration cycle row - Compact widget bell.slash glyph - M keyboard shortcut in the Events tab, with footer hint Add MuteStateTests covering the transient state, mute-row order, and the countdown label rounding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e CLI Delete QuotaProbe — the only code that read the Claude OAuth token from the macOS Keychain (SecItemCopyMatching) or the plaintext ~/.claude/.credentials.json, then called the unofficial api.anthropic.com/api/oauth/usage endpoint directly. Claude quota now comes solely from `claude --print /usage` (ClaudeCliQuotaProbe), which reads its own keychain, so our process never touches the keychain or hits the API. On a CLI hard-fail (no `claude` on PATH, not signed in, or unparseable output) the Usage tab now surfaces an error pointing at `claude /usage` instead of falling back to the keychain/API path. Also drop the now-dead usingPlaintextCredentials / usingClaudeCliProbe flags, the Settings probe-source status label, and QuotaProbeRetryAfterTests; reword the empty-state copy and comments (no more "unofficial endpoint" / keychain prompt language). Codex/Antigravity probes and GitHub's keychain use are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StuBehan
left a comment
There was a problem hiding this comment.
Two correctness gaps, both small fixes; plus non-blocking cleanup, detailed inline.
1. Panel.swift:2081 — snooze re-fire bypasses the mute gate. snoozeEvent's timer re-posts via postBanner(for:), skipping the if nav.isMuted { return } gate in postBannerIfNeeded. A snoozed prompt fires a banner during an active mute window — the only involuntary banner path the gate doesn't cover. Fix: re-post through postBannerIfNeeded(current.with(snoozedUntil: nil)) (also picks up session-mute/focus handling), or guard with guard !nav.isMuted else { return }.
2. Panel.swift:1302 — hard-fail quota error is never shown when a stale snapshot exists (inline).
Note: the global-mute gate also suppresses bypassMute (welcome/critical) events. The comment documents this as intended — flagging to confirm it's deliberate.
| // Soft-fail: leave the prior snapshot in place, no error. | ||
| } else { | ||
| // Hard-fail: the CLI couldn't run or its output didn't parse. | ||
| self.nav.quotaError = "Claude usage unavailable — run `claude /usage` to check your session." |
There was a problem hiding this comment.
Hard-fail sets quotaError but leaves nav.quota populated. emptyState (the error surface) only renders when availableUsageClients is empty, and a stale Claude snapshot keeps .claude available (PanelNav.swift:469), so the old bars keep rendering and this error is never shown. After the claude CLI is removed/signed out, the tab shows stale usage as current with no error.
Fix: clear nav.quota and quotaLastUpdated on hard-fail, or thread a stale/error flag into the tier view.
There was a problem hiding this comment.
Fixed in 400e60d — the hard-fail branch now clears nav.quota + nav.quotaLastUpdated, so once the claude CLI is removed/signed out the Usage tab surfaces the error state instead of rendering the stale snapshot as current. Good catch; this became reachable exactly because the API fallback that previously masked it is gone.
- Snooze re-fire now respects an active global mute. The snooze timer posts a banner directly, bypassing postBannerIfNeeded's `if nav.isMuted` gate; guard it so a snoozed nudge can't fire a banner during a mute window. - muteFor now cuts any in-flight voice utterance (Speaker.stopAllAudio), making good on the "silences an in-flight nudge" contract — previously it only dismissed the banner while a running Speaker.speak played on. - Quota hard-fail drops the stale snapshot (nav.quota/quotaLastUpdated) so the Usage tab shows the error state instead of rendering old bars as if current once the claude CLI is gone/signed out. - Cold rate-limited soft-fail (no prior snapshot) surfaces a rate-limit note instead of an indefinite "Loading…" spinner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review @StuBehan. Pushed
On the Cleanup suggestions captured as follow-ups (noted inline on |
- Add the timed-mute feature: M key, header bell, menu-bar submenu, Settings row, STACKNUDGE_MUTE_DURATION_MIN, in-memory/auto-lift behavior. - Rewrite the Usage-tab data source: quota now comes from `claude /usage` via the CLI, so stack-nudge never touches the keychain or the API — no more password prompt. Drop the stale /api/oauth/usage + keychain copy. - Fix pre-existing tab errors: panel has five tabs (Tickets was missing); Settings is Cmd+5, Tickets is Cmd+4. - Soften the uninstall note now that we create no keychain entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
This branch carries two independent changes.
1. Timed global mute for notifications (
feat)A "mute everything for X minutes" control that suppresses every interrupting output — banner, sound, voice, and the activate-immediately focus jump — for all events including permission prompts — then auto-lifts when the timer expires. Events still flow into the panel while muted.
postBannerIfNeededdoes the suppression.STACKNUDGE_MUTE_DURATION_MIN, allowed 15/30/60/120, default 30).Surfaces
bell.slashicon + countdown badge while mutedbell.slashglyph while mutedMkeyboard shortcut in the Events tab, with footer hint2. Drop keychain/API Claude usage probe, rely solely on the
claudeCLI (refactor)Removes
QuotaProbe— the only code that read the Claude OAuth token from the macOS Keychain (SecItemCopyMatching) or the plaintext~/.claude/.credentials.json, then called the unofficialapi.anthropic.com/api/oauth/usageendpoint directly.Claude quota now comes solely from
claude --print /usage(ClaudeCliQuotaProbe), which reads its own keychain — so StackNudge never touches the keychain or hits the API, eliminating the periodic password prompts caused by Claude Code rotating its keychain item.Also removes the now-dead
usingPlaintextCredentials/usingClaudeCliProbeflags, the Settings "probe source" status label, andQuotaProbeRetryAfterTests; rewords the empty-state copy and comments (no more "unofficial endpoint" / keychain-prompt language).Warning
User-visible behavioral change: without the
claudeCLI on PATH / signed in, the Usage tab now shows "Claude usage unavailable — runclaude /usageto check your session" instead of silently falling back to the keychain/API. Fine for this app's audience (it exists to watch Claude Code), but a conscious trade-off. Codex/Antigravity probes and GitHub's keychain use are untouched.Docs
README.mdbrought in line with both changes:Mrow in the Events-tab table, theMute notifications ▸menu-bar row, the Settings-tab Mute/Resume + duration row, and the compact-widget glyph (no countdown there — corrected)./api/oauth/usage/ keychain "Always Allow" copy) and softened the uninstall note now that we create no keychain entries.Cmd+4, and Settings isCmd+5(was documented asCmd+4).Testing
./build.shclean + Developer-ID signed after each change and after mergingorigin/main.MuteStateTests(transient state, mute-row order, countdown-label rounding); removed the obsoleteQuotaProbeRetryAfterTests;ClaudeCliQuotaProbeTestsunchanged.Mkey, menu submenu, Settings row + banner, and confirmed actual suppression of banner/sound/voice/permission-prompt while events still land in the panel and the timer auto-lifts.🤖 Generated with Claude Code